5주차. GraphRAG 실전 — graphrag index부터 Neo4j 지식 그래프까지

출처: 『RAG 마스터: 랭체인으로 완성하는 LLM 서비스』(프리렉 2025) 5장의 실전 라이브코딩 강의(NewRunDev). 개념 backbone 은 book/Master_LangChain_LLM_Service/5장 참조.

원서 5장은 구글 코랩 + OpenAI API + Neo4j Aura(클라우드)로 GraphRAG 를 돌린다. 이 강의는 같은 파이프라인을 인터넷 API 키 하나 없이 로컬 Docker 컨테이너 안에서, LM Studio 로컬 모델과 로컬 Neo4j 5.12 만으로 완주한다. 그 과정에서 마주치는 버전·환경 차이를 하나씩 뚫는 것이 이 주차의 핵심 가치다.


학습 목표

이 주차를 끝내면 다음을 할 수 있다.

  • 클라우드 API 없이 Microsoft GraphRAG CLI 를 LM Studio 로컬 모델에 연결해 인덱싱·질의를 실행한다.
  • GraphRAG 3.x 의 실제 워크플로우와 parquet 산출물 스키마를 읽고, 책이 기술한 v1.x 스키마와의 차이를 식별한다.
  • 로컬 검색과 글로벌 검색이 어떤 질문에 왜 유리한지, 실제 답변 결과로 비교 판단한다.
  • GraphRAG 의 parquet 산출물을 Neo4j 지식 그래프(노드·관계)로 배치 적재한다.
  • Neo4j 5.12 환경에서 벡터 인덱스·임베딩 차원 제약을 우회해 로컬·글로벌 검색을 코드 수준에서 직접 구현한다.

전체 흐름도

원본 문서(조지 게어 헨리 『How to Invest Money』, 1908)를 지식 그래프로 만들고, 두 경로(CLI·Neo4j)로 질의하는 5개 실습의 전체 흐름.

[ How_to_invest_money.txt ]  (약 150KB)
   │  practice5-1: graphrag init → settings.yaml/.env 를 LM Studio 로 재구성
   ▼
[ graphrag index ]  practice5-2
   │  10개 워크플로우: 청크 분할 → 엔티티/관계 추출 → 레이든 커뮤니티 → 커뮤니티 리포트 → 임베딩
   ▼
[ output/*.parquet + output/lancedb ]
   ├─ practice5-3: graphrag query --method local / global  (CLI 질의, 로컬 vs 글로벌 비교)
   │
   └─ practice5-4: parquet → Neo4j 배치 적재 (__Document__/__Chunk__/__Entity__/__Community__/Finding)
          │
          ▼
      [ 로컬 Neo4j 5.12 지식 그래프 ]
          │  practice5-5: 엔티티 임베딩 + 벡터 인덱스
          ├─ 로컬 검색: 벡터 유사 엔티티 → 청크·리포트·관계 확장 → LLM 답변
          └─ 글로벌 검색: 커뮤니티 리포트 맵(점수) → 리듀스(통합) → LLM 답변
  • 노트북 5-1~5-3 은 GraphRAG CLI 를 그대로 쓰고, 5-4~5-5 는 그 산출물을 Neo4j 로 옮겨 검색을 직접 구현한다.
  • 실측 규모: 텍스트 유닛 29개, 엔티티 169개, 관계 184개, 커뮤니티 30개, 커뮤니티 리포트 30개.

0. 사전 필수 용어

  • GraphRAG (Microsoft) — 문서를 지식 그래프로 변환하고 커뮤니티 요약을 인덱스로 삼는 RAG 파이프라인이자 그 CLI 패키지. 이 강의의 설치 버전은 3.x 계열이며, 책이 설명하는 1.x 와 워크플로우 이름·산출물 스키마가 다르다.
  • LM Studio — 로컬 PC 에서 GGUF 모델을 OpenAI 호환 API(/v1/chat/completions, /v1/embeddings)로 서빙하는 데스크톱 도구. 키 검증을 하지 않아 더미 키로 접속한다.
  • LiteLLM — 수십 개 LLM 제공자를 하나의 인터페이스로 감싸는 라이브러리. GraphRAG 3.x 는 내부적으로 LiteLLM 을 통해 {model_provider}/{model} 형식으로 모델을 호출한다.
  • completion model / embedding model — GraphRAG 가 쓰는 두 종류의 모델. 완성 모델은 엔티티 추출·커뮤니티 리포트 생성(구조화 출력)에, 임베딩 모델은 엔티티·텍스트 벡터화에 쓰인다.
  • non-thinking (nothink) 모델 — 추론(reasoning) 토큰을 내보내지 않는 모델. 구조화 출력(JSON 스키마 강제)과 결합할 때 추론 모델의 응답이 깨지는 문제를 피하기 위해 이 강의가 완성 모델로 선택한 것.
  • 텍스트 유닛(text unit) / 청크 — 원문을 일정 토큰 단위로 자른 조각. GraphRAG 산출물의 text_units.parquet 에 해당한다.
  • 엔티티(entity) / 관계(relationship) — 문서에서 추출한 개체(PERSON·ORGANIZATION·GEO 등)와 그 사이의 연결. 지식 그래프의 노드·에지가 된다.
  • 커뮤니티(community) / 커뮤니티 리포트 — 레이든 알고리즘으로 묶인 밀접한 엔티티 그룹, 그리고 각 그룹을 LLM 이 요약한 리포트(title·summary·rank·findings). 글로벌 검색의 검색 대상이다.
  • 레이든(Leiden) 알고리즘 — 커뮤니티 탐지 알고리즘. 계층적(level 0=포괄, level 1=세부)으로 커뮤니티를 구성한다.
  • 로컬 검색 / 글로벌 검색 — 특정 엔티티에서 출발해 확장하는 방식(로컬), 커뮤니티 리포트 전체를 맵-리듀스로 훑는 방식(글로벌).
  • Neo4j / Cypher / APOC — 그래프 DBMS, 그 질의 언어, 그리고 배치 적재·라벨 조작을 돕는 확장 프로시저 라이브러리.
  • MRL truncation — Matryoshka 임베딩의 앞쪽 차원만 잘라 써도 의미가 보존되는 성질을 이용해, 4096차원 벡터를 1024차원으로 줄이는 것.

1. 로컬 GraphRAG 환경 구성 — LM Studio 연동 (practice5-1)

책은 코랩에서 pip install graphrag 후 OpenAI 키를 .env 에 넣고, 작업 폴더를 구글 드라이브에 만든다. 강의는 같은 4단계(설치 → 작업 디렉터리 → graphrag init → 설정 재구성)를 밟되, 모든 것을 컨테이너 내부와 로컬 모델로 돌린다.

1.1 왜 완성 모델이 "nothink" 모델이어야 하는가 (책은 안 다루는 실전 함정)

GraphRAG 의 커뮤니티 리포트 생성 단계는 LLM 에 JSON 스키마를 강제하는 구조화 출력을 요청한다. 이때 LM Studio 에 추론(thinking) 모델을 연결하면, 실제 JSON 응답 전체가 reasoning_content 에 담기고 content 는 빈 문자열로 반환되는 버그가 있어 스키마 검증이 매번 litellm.JSONSchemaValidationError 로 실패한다. 순수 텍스트 질의응답에서는 두 필드가 정상 분리되므로, 구조화 출력과 결합될 때만 터지는 문제다. 그래서 이 강의는 추론이 꺼진 unsloth/Qwen3.6-35B-A3B-MTP-GGUF-nothink 를 완성 모델로 고정한다.

from openai import OpenAI

# LM Studio 는 OpenAI 호환 서버라 base_url 만 바꾸면 openai SDK 를 그대로 쓴다.
# host.docker.internal 은 컨테이너에서 호스트 PC(=LM Studio) 를 가리키는 특수 호스트명.
LMSTUDIO_BASE_URL = "http://host.docker.internal:12345/v1"
LMSTUDIO_API_KEY  = "lm-studio"   # LM Studio 는 키를 검증하지 않으므로 더미

LLM_MODEL   = "unsloth/Qwen3.6-35B-A3B-MTP-GGUF-nothink"  # 구조화 출력 호환을 위해 non-thinking 선택
EMBED_MODEL = "qwen3-embedding-8b:mp"

client = OpenAI(base_url=LMSTUDIO_BASE_URL, api_key=LMSTUDIO_API_KEY)

# 인덱싱을 시작하기 전에 두 모델이 실제로 응답하는지 먼저 확인한다.
# 여기서 실패하면 뒤의 수십 분짜리 인덱싱이 통째로 무의미해지므로 값싼 사전 검증이다.
resp = client.chat.completions.create(
    model=LLM_MODEL,
    messages=[{"role": "user", "content": "연결 테스트. 'ok'라고만 답하세요."}],
    max_tokens=20,
)
emb = client.embeddings.create(model=EMBED_MODEL, input="연결 테스트")
print("EMBED:", len(emb.data[0].embedding), "차원")   # → 4096

책 vs 강의 — 책은 OpenAI 클라우드 완성 모델(gpt-4o 계열)을 API 키로 바로 붙인다. 강의는 로컬 35B 모델을 쓰되, "그냥 로컬 모델이면 된다"가 아니라 구조화 출력과 호환되는 non-thinking 모델이라는 조건까지 맞춰야 한다는 걸 보여준다. 로컬 LLM 으로 GraphRAG 를 돌릴 때만 드러나는 함정이다.

1.2 graphrag init 과 GraphRAG 3.x 스키마

graphrag initsettings.yaml(전체 파이프라인 설정)과 .env(환경 변수)를 생성한다. --force 로 항상 기본 템플릿부터 새로 만든 뒤, LM Studio 용 커스터마이징은 코드로 매번 동일하게 적용한다. 수작업 편집이 아니라 코드로 재구성해야 노트북을 몇 번 다시 돌려도 같은 결과가 나온다(재실행 안전).

import subprocess

result = subprocess.run(
    ["graphrag", "init",
     "--root", str(WORKING_DIR),
     "--model", LLM_MODEL,          # 기본 완성 모델명을 미리 박아둠
     "--embedding", EMBED_MODEL,
     "--force"],                    # 기존 설정을 항상 기본 템플릿으로 덮어써 재실행을 멱등하게
    capture_output=True, text=True,
)
# 생성물: ['.env', 'cache', 'input', 'logs', 'output', 'prompts', 'settings.yaml']

1.3 settings.yaml 을 LM Studio 로 돌리는 두 가지 핵심 수정

graphrag init 이 만든 기본 settings.yaml 을 파이썬으로 파싱해 두 곳을 고친다.

import yaml

with open(settings_path, "r", encoding="utf-8") as f:
    settings = yaml.safe_load(f)

# (1) 완성/임베딩 모델 각각에 api_base 를 주입 → LiteLLM 이 OpenAI 대신 LM Studio 로 호출한다.
#     model_provider 는 openai 그대로 두고 api_base 만 바꾸는 게 OpenAI 호환 서버 연결의 정석.
settings["completion_models"]["default_completion_model"]["api_base"] = LMSTUDIO_BASE_URL
settings["embedding_models"]["default_embedding_model"]["api_base"]  = LMSTUDIO_BASE_URL

# (2) 동시 요청 수를 1 로 강제 → 로컬 단일 모델에서 프롬프트 캐시를 지키는 결정적 설정 (아래 설명).
settings["concurrent_requests"] = 1

with open(settings_path, "w", encoding="utf-8") as f:
    yaml.safe_dump(settings, f, allow_unicode=True, sort_keys=False)

concurrent_requests 를 반드시 1로 낮춰야 하는 이유 — GraphRAG 기본값은 concurrent_requests: 25 다. 서버 인스턴스를 여러 개 두는 OpenAI/Azure 에는 적합하지만, LM Studio 처럼 단일 모델 인스턴스가 요청을 처리하는 로컬 환경에서는 요청이 인터리빙되면서 llama.cpp 의 프롬프트(KV) 캐시가 매 요청마다 다른 컨텍스트로 밀려난다(evict). 그 결과 매 요청이 프롬프트를 처음부터 다시 처리해 청크당 처리 시간이 수십 배 느려진다(청크당 수십 초 → 10분 이상). 로컬 단일 서빙에서는 직렬화(=1)가 훨씬 빠르다.

책 vs 강의 — 책의 settings.yamlmodels: 하나에 api_key/type: openai_chat/model 을 두는 v1.x 스키마다. 강의의 3.x 는 completion_models / embedding_models 로 분리되고, 각 모델이 model_provider/api_base/retry 를 갖는다. chunks:·extract_graph:·community_reports: 같은 책의 설정 키도 3.x 에서는 위치·이름이 달라질 수 있으니 생성된 settings.yaml 을 직접 열어 확인하는 습관이 중요하다.

1.4 입력 문서 배치 — copy 대신 copyfile

import shutil

# shutil.copy(=copyfile+copymode) 대신 copyfile 만 사용한다.
# 컨테이너 재빌드로 파일 소유자/권한이 바뀌면 copymode 의 chmod 가 PermissionError 를 낼 수 있는데,
# 여기서는 내용만 input/ 으로 옮기면 되므로 권한 복사를 생략하는 편이 더 안전하다.
shutil.copyfile(DATA_PATH, input_dir / Path(DATA_PATH).name)

2. graphrag index — 지식 그래프 구축 (practice5-2)

graphrag index --root ./working_directory 한 줄이 전체 인덱싱을 실행한다. 진행 상황이 워크플로우별로 실시간 출력된다.

!graphrag index --root ./practice5/working_directory

2.1 책의 7단계 vs 3.x 의 10 워크플로우

책은 인덱싱을 7단계 워크플로우(create_base_text_unitscreate_final_community_reports)로 설명한다. 설치된 3.x 는 역할은 같지만 이름이 다른 10개 워크플로우로 실행한다.

load_input_documents → create_base_text_units → create_final_documents →
extract_graph → finalize_graph → extract_covariates → create_communities →
create_final_text_units → create_community_reports → generate_text_embeddings

두 범주로 요약하면 (책과 동일한 분류): Base processing(문서 분할 → 엔티티 추출 → 기본 그래프 → 커뮤니티 탐지 → 임베딩)과 Enrichment(엔티티·관계·커뮤니티 정제, 커뮤니티 요약 생성)다.

로컬 LLM 소요 시간 — 로컬 35B 모델로 엔티티 추출·리포트를 생성하면 요청당 평균 40~70초가 걸린다. 이 예제 문서(약 150KB, 청크 29개) 전체 인덱싱에 수십 분 이상 걸릴 수 있다. concurrent_requests: 1 로 직렬화했기 때문에 더더욱 오래 걸리지만, 위에서 본 캐시 이유로 그게 오히려 전체적으로는 빠르다.

2.2 산출물 — parquet 6종 + LanceDB

인덱싱이 끝나면 output/ 에 parquet 파일들이, output/lancedb/ 에 임베딩 벡터 인덱스가 생긴다.

import pandas as pd

entities = pd.read_parquet(output_dir / "entities.parquet")
# 각 엔티티는 title·type·description 과 degree(연결 정도, 다른 엔티티와의 관계 수)를 갖는다.
# degree 가 높을수록 그래프에서 중심적인 엔티티다.
entities[["title", "type", "description", "degree"]].head(10)
# → PROJECT GUTENBERG(ORGANIZATION, degree 6), GEORGE GARR HENRY(PERSON, degree 3) ...

커뮤니티 구조를 보면 계층(level)과 소속 엔티티(entity_ids), 하위 커뮤니티(children)를 확인할 수 있다.

communities_df = pd.read_parquet(output_dir / "communities.parquet")
sample = communities_df.iloc[0]
# community 0 은 level 0(최상위)이고 엔티티 20개를 묶으며, 하위 커뮤니티 [10, 11, 12] 를 거느린다.
print(sample["community"], sample["level"], sample["size"], list(sample["children"]))

커뮤니티 리포트는 글로벌 검색이 훑는 대상이다.

community_reports = pd.read_parquet(output_dir / "community_reports.parquet")
# 각 리포트: community 번호·level·rank(중요도)·title·summary·findings.
# 예: level 1, rank 3.0, "Savings Banks, Railroad Bonds, and Municipal Bond Markets"

실측 규모: 텍스트 유닛 29 / 엔티티 169 / 관계 184 / 커뮤니티 30 / 커뮤니티 리포트 30.

책 vs 강의 — 스키마 차이 3가지 (뒤의 Neo4j 적재에서 직접 부딪힌다): 1. 파일명에 create_final_ 접두사가 없다. 책의 create_final_entities.parquet → 3.x 는 entities.parquet. 2. 책이 쓰는 create_final_nodes.parquet(엔티티별 community/x/y 2D 좌표) 자체가 없다. 3.x 는 그래프 임베딩·좌표 스냅숏이 기본으로 꺼져 있어(snapshots.graphml/embeddings: false) 시각화용 좌표를 생성하지 않는다. 엔티티-커뮤니티 소속은 communities.parquetentity_ids 목록에서 직접 끌어와야 한다. 3. text_units.parquet 의 문서 참조 컬럼이 책의 document_ids(리스트)가 아니라 단수형 document_id 다.


3. 로컬 검색 vs 글로벌 검색 — CLI 질의 (practice5-3)

같은 질문을 --method local--method global 로 던져, 답변 범위·깊이가 어떻게 달라지는지 비교한다.

# 글로벌: 커뮤니티 리포트 전체를 맵-리듀스로 종합 → 문서 전체를 아우르는 답변
!graphrag query --root ./practice5/working_directory --method global "이 책 전반에서 다루는 투자 대상의 종류와 저자가 강조하는 투자 원칙은?"

# 로컬: 질문과 가장 강하게 연결된 소수 엔티티에서 출발 → 그 주변 청크·관계·리포트로 확장
!graphrag query --root ./practice5/working_directory --method local "조지 게어 헨리는 어떤 인물이며 어느 회사에서 어떤 직책을 맡았나요?"

3.1 관찰된 결과의 차이

  • 포괄적 질문(투자 대상 종류·원칙)에는 글로벌이 채권·주식·부동산 담보·지방채까지 문서 전체 주제를 균형 있게 요약했다. 답변에 [Data: Reports (0, 4, 26, ...)] 처럼 여러 커뮤니티 리포트가 근거로 붙는다.
  • 세부적 질문(저자의 경력)에는 로컬이 "가란티 트러스트 컴퍼니 부사장, 1908년 Funk & Wagnalls 출간" 처럼 특정 엔티티와 그 관계에서 바로 정확한 사실을 뽑았다. 근거는 [Data: Entities (2); Relationships (0)] 처럼 엔티티·관계 단위로 붙는다.
  • 흥미로운 역방향: 포괄적 질문을 로컬로 던지면 저자·출판사 같은 소수 엔티티에서 출발하기 때문에 상대적으로 좁은 답이 나오는 경향이 있다. 즉 질문의 성격과 검색 방식을 맞추는 것이 답변 품질을 좌우한다.

책 vs 강의 — 책의 선택 기준표(포괄=글로벌, 세부=로컬)를 강의는 실제 답변 텍스트와 근거 인용 형식(Reports vs Entities/Relationships) 으로 눈으로 확인시킨다. 그리고 OpenAI 키 없이 로컬 모델만으로 이 비교가 그대로 재현된다는 것을 보인다.


4. GraphRAG 산출물을 Neo4j로 적재 (practice5-4)

책은 Neo4j Aura(클라우드)에 새 인스턴스를 만들고 parquet 를 올린다. 강의는 infra2 의 docker-compose 에 이미 떠 있는 로컬 Neo4j 5.12(APOC 포함) 를 쓴다. 이 컨테이너는 작업 컨테이너와 같은 외부 도커 네트워크(linker)에 붙어 bolt://neo4j:7687 로 바로 접속된다.

from neo4j import GraphDatabase

NEO4J_URI = "bolt://neo4j:7687"   # 클라우드가 아니라 같은 도커 네트워크의 로컬 컨테이너
driver = GraphDatabase.driver(NEO4J_URI, auth=("neo4j", "password"))
driver.verify_connectivity()      # 적재 전 연결부터 확인 (사전 검증)

4.1 배치 임포트 헬퍼

대량 행을 한 건씩 넣으면 느리므로, DataFrame 을 배치로 잘라 UNWIND 로 벌크 적재한다.

def batched_import(statement, df, batch_size=1000):
    total = len(df)
    for start in range(0, total, batch_size):
        batch = df.iloc[start : min(start + batch_size, total)]
        # UNWIND 는 넘겨준 rows 리스트를 한 건씩 풀어 각 행을 value 로 뒤 쿼리에 전달한다.
        # 덕분에 statement 는 한 행짜리처럼 짧게 쓰면서 실제로는 배치 전체를 한 트랜잭션에 처리한다.
        driver.execute_query(
            "UNWIND $rows AS value " + statement,
            rows=batch.to_dict("records"),
            database_=NEO4J_DATABASE,
        )
    return total

4.2 공유 인스턴스 안전 — 라벨 기준으로만 삭제

이 Neo4j 는 다른 프로젝트와 함께 쓰는 공용 DB 다. 전체를 비우면 남의 데이터가 날아간다. GraphRAG 가 쓰는 이중 언더스코어 라벨(__Document__·__Chunk__·__Entity__·__Community__)과 Finding 만 골라 지운다.

GRAPHRAG_LABELS = ["__Document__", "__Chunk__", "__Entity__", "__Community__", "Finding"]
for label in GRAPHRAG_LABELS:
    # 이 실습이 만든 라벨만 DETACH DELETE → 공유 DB 의 다른 라벨 데이터는 건드리지 않는다.
    driver.execute_query(f"MATCH (n:`{label}`) DETACH DELETE n", database_=NEO4J_DATABASE)

4.3 제약 조건 — 중복 방지 + MERGE 색인

statements = [
    "create constraint chunk_id if not exists for (c:__Chunk__) require c.id is unique",
    "create constraint entity_id if not exists for (e:__Entity__) require e.id is unique",
    "create constraint entity_title if not exists for (e:__Entity__) require e.name is unique",
    "create constraint related_id if not exists for ()-[rel:RELATED]->() require rel.id is unique",
    # community 는 community 속성이 유일키 (id 가 아님에 주의)
    "create constraint community_id if not exists for (c:__Community__) require c.community is unique",
]

유일성 제약은 두 가지 일을 동시에 한다. 같은 id 노드가 두 번 저장되는 것을 DB 가 직접 막고, 뒤이은 대량 MERGE 가 기존 노드를 빠르게 찾도록 하는 색인 역할도 겸한다.

4.4 노드·관계 적재 순서

문서 → 청크 → 엔티티 → 관계 → 커뮤니티 → 리포트 순으로 적재한다(뒤 단계가 앞 단계 노드를 MATCH 하므로 순서가 중요하다).

# ② 청크: 책은 document_ids(리스트)라 UNWIND 가 필요하지만, 3.x 는 청크당 문서 하나(document_id, 단수)라
#    바로 MATCH 로 연결한다 — 위에서 본 스키마 차이가 코드에 그대로 반영되는 지점.
text_statement = """
MERGE (c:__Chunk__ {id:value.id})
SET c += value {.text, .n_tokens}
WITH c, value
MATCH (d:__Document__ {id: value.document_id})
MERGE (c)-[:PART_OF]->(d)
"""

# ③ 엔티티: type 을 동적 라벨로 붙이고(APOC), 엔티티가 등장한 청크와 HAS_ENTITY 로 연결.
entity_statement = """
MERGE (e:__Entity__ {id: value.id})
SET e.human_readable_id = value.human_readable_id,
    e.description = value.description,
    e.name = coalesce(replace(value.title, '"', ''), 'Unknown')
WITH e, value
CALL apoc.create.addLabels(e, CASE WHEN coalesce(value.type,"")="" THEN []
    ELSE [apoc.text.upperCamelCase(replace(value.type,'"',''))] END) YIELD node
UNWIND value.text_unit_ids AS text_unit
MATCH (c:__Chunk__ {id: text_unit})
MERGE (c)-[:HAS_ENTITY]->(e)
"""

커뮤니티 적재가 스키마 차이를 가장 크게 흡수하는 곳이다. 책은 create_final_nodes.parquet(엔티티별 community 컬럼)로 엔티티-커뮤니티 연결을 만들지만 3.x 엔 그 파일이 없다. 대신 communities.parquet 가 이미 담은 entity_ids 목록에서 직접 연결하고, 관계를 통한 간접 연결(relationship_ids)도 belt-and-suspenders 로 함께 건다.

community_statement = """
MERGE (c:__Community__ {community: value.community})
SET c.id = value.id, c.level = value.level, c.title = value.title
WITH c, value
UNWIND value.text_unit_ids AS text_unit_id
MATCH (t:__Chunk__ {id: text_unit_id})
MERGE (c)-[:HAS_CHUNK]->(t)
WITH distinct c, value
UNWIND value.entity_ids AS entity_id
MATCH (e:__Entity__ {id: entity_id})
MERGE (e)-[:IN_COMMUNITY]->(c)     -- 책의 create_final_nodes 대신 entity_ids 로 직접 연결
WITH distinct c, value
UNWIND value.relationship_ids AS rel_id
MATCH (start:__Entity__)-[:RELATED {id: rel_id}]->(end:__Entity__)
MERGE (start)-[:IN_COMMUNITY]->(c)
MERGE (end)-[:IN_COMMUNITY]->(c)
"""

커뮤니티 리포트는 커뮤니티 노드에 속성을 추가하고, findings 를 개별 Finding 노드로 펼친다.

community_report_statement = """
MERGE (c:__Community__ {community: value.community})
SET c.rank = value.rank,
    c.rating_explanation = value.rating_explanation,   -- 책은 rank_explanation, 3.x 는 rating_explanation
    c.full_content = value.full_content,
    c.summary = value.summary
WITH c, value
UNWIND range(0, size(value.findings)-1) AS finding_idx
WITH c, value, finding_idx, value.findings[finding_idx] AS finding
MERGE (c)-[:HAS_FINDING]->(f:Finding {id: finding_idx})
SET f += finding
"""

적재 검증 결과(실측): __Entity__ 169 / __Community__ 30 / __Chunk__ 29 / __Document__ 1 / Finding 139. 관계는 RELATED 184 / HAS_ENTITY 226 / IN_COMMUNITY 200 / HAS_FINDING 139 / PART_OF 29 / HAS_CHUNK 81.

책 vs 강의 총정리(적재) — 클라우드 Aura → 로컬 5.12, document_idsdocument_id, create_final_nodes 부재 → entity_ids 직접 연결, rank_explanationrating_explanation. 강의는 "책 코드를 그대로 붙여넣으면 안 되는 이유"를 컬럼 단위로 짚는다.


5. Neo4j 기반 로컬·글로벌 검색 직접 구현 (practice5-5)

이제 CLI 가 아니라 랭체인·Cypher 로 로컬·글로벌 검색을 직접 짠다. GraphRAG 내부가 무엇을 하는지 코드 수준에서 드러난다.

5.1 Neo4j 5.12 의 벡터 인덱스 제약 (책은 못 만나는 벽)

책은 Neo4jVector.from_existing_graph() 가 벡터 인덱스를 자동 생성해 준다. 하지만 이 클래스는 내부적으로 Neo4j 5.15+ 에서 도입된 CREATE VECTOR INDEX Cypher 문법을 쓴다. infra2 의 Neo4j 는 5.12 라 이 문법이 아예 파싱되지 않는다(CypherSyntaxError: Invalid input 'VECTOR'). 그래서 5.12 에서도 동작하는 예전 프로시저 방식으로 인덱스를 직접 만들고 조회한다. 이 프로시저는 인덱스 차원을 최대 2048 로 제한하므로, 4096차원 임베딩을 MRL truncation 으로 1024차원까지 줄여 넣는다.

PROJ_DIM = 1024   # 구버전 프로시저 상한 2048 보다 충분히 작게

def embed(text: str):
    # MRL 임베딩은 앞쪽 차원에 정보가 집중돼, 앞 PROJ_DIM 개만 잘라 써도 의미가 보존된다.
    # 이렇게 줄여야 Neo4j 5.12 벡터 인덱스의 차원 상한을 넘지 않는다.
    vec = client.embeddings.create(model=EMBED_MODEL, input=text).data[0].embedding
    return vec[:PROJ_DIM]

# 5.15+ 문법(CREATE VECTOR INDEX) 대신 5.12 에서 동작하는 예전 프로시저로 인덱스 생성.
driver.execute_query(f"DROP INDEX {VECTOR_INDEX_NAME} IF EXISTS", database_=NEO4J_DATABASE)
driver.execute_query(
    "CALL db.index.vector.createNodeIndex($index, '__Entity__', 'embedding', $dim, 'cosine')",
    index=VECTOR_INDEX_NAME, dim=PROJ_DIM, database_=NEO4J_DATABASE,
)

각 엔티티의 description 을 임베딩해 embedding 속성으로 저장한 뒤 위 인덱스를 건다.

5.2 로컬 검색 — 벡터 유사 엔티티에서 그래프로 확장

로컬 검색은 세 부품의 결합이다. ① 질문과 벡터 유사한 엔티티 찾기 → ② 그 엔티티 주변(청크·커뮤니티 리포트·관련 엔티티) 확장 → ③ 정리한 컨텍스트로 LLM 답변.

def vector_search_entities(query, k=3):
    query_vec = embed(query)
    # 예전 프로시저 db.index.vector.queryNodes 로 코사인 유사 상위 k 엔티티를 가져온다.
    result = driver.execute_query(
        "CALL db.index.vector.queryNodes($index, $k, $vec) YIELD node, score "
        "RETURN node.id AS id, node.name AS name, node.description AS description, score",
        index=VECTOR_INDEX_NAME, k=k, vec=query_vec, database_=NEO4J_DATABASE,
    )
    return result.records

def fetch_entity_context(entity_name):
    # 하나의 엔티티에서 세 방향으로 그래프를 확장한다 — 이것이 "로컬" 검색의 정의.
    # (1) 이 엔티티가 등장한 원문 청크, (2) 이 엔티티가 속한 커뮤니티 리포트, (3) RELATED 로 이어진 이웃 엔티티.
    context = {"name": entity_name}
    context["text_chunks"] = [r["text"] for r in driver.execute_query(
        "MATCH (e:__Entity__ {name:$n})<-[:HAS_ENTITY]-(c:__Chunk__) RETURN c.text AS text",
        n=entity_name, database_=NEO4J_DATABASE).records] or ["No text chunk available"]
    context["community_reports"] = [r["report"] for r in driver.execute_query(
        "MATCH (e:__Entity__ {name:$n})-[:IN_COMMUNITY]->(com:__Community__) RETURN com.full_content AS report",
        n=entity_name, database_=NEO4J_DATABASE).records] or ["No community report available"]
    context["related_entities"] = [{"name": r["name"], "description": r["description"]}
        for r in driver.execute_query(
        "MATCH (e:__Entity__ {name:$n})-[:RELATED]-(rel:__Entity__) RETURN rel.name AS name, rel.description AS description",
        n=entity_name, database_=NEO4J_DATABASE).records]
    return context

이렇게 모은 컨텍스트를 사람이 읽기 좋은 마크다운으로 정리(create_structured_context)한 뒤 LLM 에 넣으면, "조지 게어 헨리는 가란티 트러스트 컴퍼니 부사장" 같은 세부 답이 나온다. CLI 로컬 검색과 같은 결과를 직접 구현으로 재현한 것이다.

5.3 글로벌 검색 — 맵-리듀스

글로벌 검색은 특정 레벨의 커뮤니티 리포트를 전부 가져와, 각각에 대해 "이 리포트로 질문에 답할 수 있으면 답과 중요도 점수를 JSON 으로 내라"(맵)고 시킨 뒤, 점수를 고려해 통합(리듀스)한다.

# 먼저 이 그래프에 실제로 어떤 커뮤니티 레벨이 있는지 확인 (level 0=포괄 10개, level 1=세부 20개).
level_result = driver.execute_query(
    "MATCH (c:__Community__) WHERE c.full_content IS NOT NULL "
    "RETURN DISTINCT c.level AS level, count(*) AS cnt ORDER BY level",
    database_=NEO4J_DATABASE)

def global_search(query, level, response_type="multiple paragraphs"):
    community_data = driver.execute_query(
        "MATCH (c:__Community__) WHERE c.level = $level AND c.full_content IS NOT NULL "
        "RETURN c.full_content AS output",
        level=level, database_=NEO4J_DATABASE).records
    # 맵: 커뮤니티 리포트 하나하나에 대해 중간 응답 + 중요도 점수 생성.
    intermediate = [map_step(query, c["output"]) for c in community_data]
    # 리듀스: 점수를 고려해 핵심 응답만 통합해 최종 마크다운 답변 작성.
    return reduce_step(intermediate, query, response_type)

맵 단계 프롬프트는 {"Answer": ..., "score": 0~100} JSON 을 강제하고, 리듀스 단계는 중요도 점수가 높은 응답 위주로 종합한다. 정보가 부족하면 "모르겠습니다"라고 답하도록 한다(환각 억제).

책 vs 강의 총정리(질의) — | | 책 | 강의 | |---|---|---| | 그래프 DB | Neo4j Aura(클라우드) | 로컬 Neo4j 5.12(도커, linker 네트워크) | | 벡터 인덱스 | Neo4jVectorCREATE VECTOR INDEX(5.15+)로 자동 | db.index.vector.createNodeIndex(구 프로시저)로 직접 | | 임베딩/LLM | OpenAI 클라우드(text-embedding-3-large, gpt-4o) | LM Studio 로컬(qwen3-embedding-8b:mp, non-thinking 35B) | | 임베딩 차원 | 3072 그대로 | 4096 → MRL truncation 으로 1024(인덱스 상한 2048 대응) |


6. 책 대비 강의의 실전 선택 총정리

이 주차 전체를 한 표로 압축하면, "책의 클라우드 튜토리얼"과 "강의의 로컬 프로덕션 재현"의 대응이 드러난다.

책(원서 5장) 강의(5주차 실습) 왜 이 선택인가
실행 환경 코랩 + 구글 드라이브 저장 Docker 컨테이너(파일 영속) 런타임 종료해도 산출물 유지, 드라이브 백업 불필요
완성 모델 OpenAI 클라우드(gpt-4o 계열) Qwen3.6-35B ...-nothink 추론 모델은 구조화 출력 시 content 가 비어 JSON 검증 실패
모델 연결 type: openai_chat + api_key model_provider: openai + api_base(LiteLLM) 3.x 스키마에서 OpenAI 호환 로컬 서버 연결
동시 요청 기본 25 1 로컬 단일 인스턴스의 프롬프트 캐시 보존(수십 배 속도차)
parquet 스키마 create_final_*, document_ids, rank_explanation, create_final_nodes(x/y) 접두사 없음, document_id, rating_explanation, entity_ids 직접 GraphRAG 3.x 의 실제 산출물 구조
그래프 DB Neo4j Aura 로컬 Neo4j 5.12(APOC, 공유 인스턴스) 클라우드 계정 없이 동일 실습, 라벨 기준 안전 삭제
벡터 인덱스 Neo4jVector 자동(5.15+ 문법) 구 프로시저 직접(5.12) 5.12 는 CREATE VECTOR INDEX 미지원
임베딩 차원 3072 4096 → 1024(MRL) 5.12 벡터 인덱스 차원 상한 2048

최신 동향 (검증 2026-07-21)

아래는 이 강의 노트북이 실제로 마주친 버전 현실과 책(2025 기준)의 차이에서 확인된 내용만 적는다. 시점에 따라 또 바뀔 수 있으므로 공식 문서를 함께 확인하라.

  • GraphRAG 3.x 의 설정·산출물 스키마가 책(v1.x)과 다르다. settings.yamlcompletion_models/embedding_models 로 분리되고, parquet 는 create_final_ 접두사가 사라졌으며 create_final_nodes(좌표) 스냅숏이 기본 비활성이다. GraphRAG 는 버전에 따라 CLI·스키마가 계속 바뀌므로 생성된 settings.yaml 과 output/.parquet 컬럼을 직접 확인*하는 것이 가장 확실하다. (공식: https://microsoft.github.io/graphrag/)
  • GraphRAG 3.x 는 LiteLLM 을 통해 모델을 호출한다. model_provider: openai + 커스텀 api_base 조합만으로 LM Studio·vLLM 등 OpenAI 호환 로컬 서버를 그대로 붙일 수 있다.
  • Neo4j 벡터 인덱스는 버전 게이팅이 있다. CREATE VECTOR INDEX 문법과 Neo4jVector 자동 생성은 5.15+ 에서 동작하고, 그 이전(5.12 등)은 db.index.vector.createNodeIndex/queryNodes 프로시저(차원 상한 2048)를 써야 한다. (공식: https://neo4j.com/docs/)
  • langchain-neo4j 파트너 패키지 분리. 책의 GraphCypherQAChain·Neo4jGraphlangchain-community 에서 langchain-neo4j 로 이동했다. 신규 코드는 from langchain_neo4j import ... 사용이 권장된다. (공식: https://python.langchain.com/docs/integrations/graphs/neo4j_cypher/)
난이도
에피소드
질문
카드를 로딩 중...
답변

클릭하거나 Space를 눌러 뒤집기

0 / 0
학습 진도 0%
이동   Space 뒤집기   R 셔플